Skip to content

Fix cppia JIT "Bad move target" converting an int subtraction to a string - #1369

Open
MeguminBOT wants to merge 2 commits into
HaxeFoundation:masterfrom
MeguminBOT:fix-cppia-jit-bad-move-target
Open

Fix cppia JIT "Bad move target" converting an int subtraction to a string#1369
MeguminBOT wants to merge 2 commits into
HaxeFoundation:masterfrom
MeguminBOT:fix-cppia-jit-bad-move-target

Conversation

@MeguminBOT

@MeguminBOT MeguminBOT commented Aug 13, 2026

Copy link
Copy Markdown

Remake of #1366

The problem

With the JIT on, a cppia module containing an ordinary integer expression fails to load:

Error : Bad move target

The failure is at module boot, because JIT compilation runs over the whole module there, so one
expression takes the entire module down. cppia with jit off runs the same code correctly.

Why

OpSub::genCode hands convert a bare sJitTemp1, where OpMult::genCode right above it passes
sJitTemp1.as(jtInt):

compiler->sub(sJitTemp1.as(jtInt), lval, sJitTemp0, false);
compiler->convert(sJitTemp1, etInt, inDest, destType);

Inside convert, the etInt to etString case moves that untyped source into an untyped
sJitArg0:

if (inSrc.uses(SLJIT_R1))
{
   move(sJitArg0, inSrc);
   ...

Neither side has a width. getCommonType(jtAny, jtAny) returns jtAny, which move() rejects with
setError("Bad move target"). That is thrown, caught in CppiaModule, and re-raised as the load
error above.

Only a String destination trips it. etFloat goes through SLJIT_CONV_F64_FROM_S32 and etObject
through intToObj, and neither of those calls move. "" + (a * b) does not trip it either, since
OpMult types its source.

The fix

One token in src/hx/cppia/Cppia.cpp, so OpSub matches OpMult:

compiler->convert(sJitTemp1.as(jtInt), etInt, inDest, destType);

That alone fixes the crash with CppiaCompiler.cpp untouched. Three other things ride along:

  • The 2-byte ArrayBuiltin read has the same omission against its byte-sized sibling. Nothing
    instantiates ArrayBuiltin with a 2-byte element, so that block never compiles. Consistency only.
  • CppiaCompiler.cpp still gets the three widths, as hardening rather than as the fix. convert is
    handed inSrcType and every other move in it already types both sides from that, so these three
    were the ones that had been missed.
  • The etObject to etFloat branch guarded on inSrc==sJitTemp1, but JitVal::operator== compares
    type, so only a bare untyped sJitTemp1 ever matched. A typed R1 source fell through to a path
    where makeAddress stomps R1 before objToFloat reads it. It guards on inSrc.uses(SLJIT_R1)
    now, like the etObject to etString branch above it. Nothing produces that shape today either,
    so this is correctness on an unreachable path.

Test

test/cppia covers it. ClientJitConvert in Client.hx holds the cases and cases/TestCommon.hx
checks the answers: the crash itself, both of the other OpSub destinations, and the three convert
paths touched here.

cd test/cppia
haxe compile-host.hxml
haxe compile-client.hxml
cd bin && ./CppiaHost.exe client.cppia -jit

The -jit matters, and RunTests.hx already runs the suite both ways. 27/27 either way.

Without the fix the -jit run fails at setupClass failed: Bad move target and exits 1, since the
module never loads. The same build without -jit reports ALL TESTS OK.

Worth saying plainly: the tests cannot isolate the hardening from the OpSub fix. With either one in
place nothing hands convert an untyped register, so no Haxe code can tell them apart. Reverting
CppiaCompiler.cpp completely with OpSub fixed still passes everything. To check the new cases are
not vacuous I dropped a line from the branch I rewrote, and exactly one test failed.

Since this is all in the JIT, I also checked it moves no generated code. Master and this branch were
built as two hosts and run against the same client.cppia, dumping the sljit LIR through
sljit_compiler_verbose: 2601 lines each, zero differences once the pointer immediates that shift
under ASLR are normalised. That follows from getData never reading type and getTarget reading it
only to choose between maxFTempCount and maxTempCount, where jtAny, jtInt and jtPointer all
take the same branch.

Reproducing by hand

class Script {
   public static function run():String {
      var a:Int = 2;

      return "" + (a - 1);
   }

   public static function main():Void {}
}

Built with haxe -m Script --cppia script.cppia and loaded from a host built with
-D scriptable --dce no, calling cpp.cppia.Host.enableJit(true) before
cpp.cppia.Module.fromData(bytes).boot().

result
before, JIT on Error : Bad move target at boot
after, JIT on 1
either way, JIT off 1
Pre-edit description:

The problem

With the JIT on, a cppia module containing an ordinary integer expression fails to load:

Error : Bad move target

The failure is at module boot, because JIT compilation runs over the whole module there, so one
expression takes the entire module down. cppia with jit off runs the same code correctly.

Why

CppiaCompiler::convert moves between two untyped registers in three places, for example when
converting an Int to a String:

if (inSrc.uses(SLJIT_R1))
{
   move(sJitArg0, inSrc);
   ...

Neither side is given a width. When both operands are untyped, getCommonType(jtAny, jtAny) returns
jtAny, which move() rejects with setError("Bad move target"). That is thrown, caught in
CppiaModule, and re-raised as the load error above.

It needs both conditions at once, which is why it is easy to miss: the source has to be in R1 and
untyped. An expression like "" + (a * b) reaches the branch but with a typed source, and
"" + ints[0] has an untyped source but does not reach the branch.

The fix

In src/hx/cppia/CppiaCompiler.cpp, give both sides a width:

move(sJitArg0.as(jtInt), inSrc.as(jtInt));          // the etString case, from etInt
move(sJitArg0.as(jtPointer), inSrc.as(jtPointer));  // the two etObject cases

Test

test/cppia covers it. ClientUntypedMove in Client.hx subtracts one array element from another
into a string, and testUntypedRegisterMove in cases/TestCommon.hx checks the answer.

cd test/cppia
haxe compile-host.hxml
haxe compile-client.hxml
cd bin && ./CppiaHost.exe client.cppia -jit

The -jit matters, and RunTests.hx already runs the suite both ways.

Without the fix, the -jit run fails at setupClass failed: Bad move target and exits 1, since the
module never loads. The same build without -jit reports ALL TESTS OK.

Reproducing by hand

class Script {
   public static function run():String {
      var a:Int = 2;
      var ints:Array<Int> = [7,8,9];

      return "" + (ints[a - 1] - ints[0]);
   }

   public static function main():Void {}
}

Built with haxe -m Script --cppia script.cppia and loaded from a host built with
-D scriptable --dce no, calling cpp.cppia.Host.enableJit(true) before
cpp.cppia.Module.fromData(bytes).boot().

result
before, JIT on Error : Bad move target at boot
after, JIT on 1
either way, JIT off 1

CppiaCompiler::convert moves between two untyped registers in three places.
getCommonType(jtAny, jtAny) returns jtAny, which move() rejects.

The cppia test suite covers it, run with -jit.
@MeguminBOT
MeguminBOT marked this pull request as draft August 16, 2026 10:16
OpSub::genCode handed convert a bare sJitTemp1 where its sibling
OpMult::genCode passes sJitTemp1.as(jtInt). That is the whole bug: with
OpSub fixed the test passes with CppiaCompiler.cpp untouched. The 2-byte
array read in ArrayBuiltin has the same omission against its byte-sized
sibling, though nothing instantiates ArrayBuiltin with a 2-byte element,
so that block never compiles.

The convert hardening stays as defence in depth, since convert is handed
inSrcType and every other move in it already types both sides from it.

The etObject to etFloat branch guarded on inSrc==sJitTemp1, but JitVal
equality includes type, so only a bare untyped sJitTemp1 ever matched. A
typed R1 source fell through to a path where makeAddress overwrote R1
before objToFloat read it. Nothing produces that shape today, so this is
correctness on an unreachable path rather than a live fix. It now guards
on inSrc.uses(SLJIT_R1) like the etObject to etString branch above it,
spills once, and shares the memory and non-memory target paths.

Tests cover the reported crash, both of the other OpSub destinations,
and the three convert paths touched here. They cannot isolate the
hardening from the OpSub fix, since with either one present nothing
hands convert an untyped register.

Emitted sljit code is unchanged: the whole cppia test client jits to
identical LIR before and after.
@MeguminBOT
MeguminBOT force-pushed the fix-cppia-jit-bad-move-target branch from d55281d to cb07560 Compare August 16, 2026 11:23
@MeguminBOT

Copy link
Copy Markdown
Author

Small update.
I went looking for where the untyped register was actually coming from, and it turns
out the original diff was fixing the wrong end of it.

OpSub::genCode passes a bare sJitTemp1 into convert, while OpMult just above it
(Cppia.cpp:6761) passes sJitTemp1.as(jtInt). That's the whole thing: fixing OpSub on its own
makes the test pass without touching CppiaCompiler.cpp at all. The 2-byte array read at
ArrayBuiltin.cpp:1485 has the same omission against its byte-sized twin at 1469, but ArrayBuiltin
never gets instantiated with a 2-byte element, so that block never compiles. I fixed it anyway for
consistency. Everything else that reaches convert is a JitTemp with an explicit type, so OpSub
was the only live producer.

I kept the three convert changes as well, since convert already gets told inSrcType and every
other move in there types both sides from it, so those three were simply the ones that had been
missed.

I also rewrote the etObject to etFloat branch while I was in there. It was checking
inSrc==sJitTemp1, but JitVal::operator== compares type too, so anything arriving as a typed R1
slipped past into the else, where makeAddress stomps R1 before objToFloat gets to read it. It
checks inSrc.uses(SLJIT_R1) now, same as the branch just above it. To be clear though, nothing
produces that shape today either, so it is correctness on an unreachable path rather than a live fix.

The tests are reworked. "" + (a - 1) triggers the crash on its own so the array is gone, and there
are now cases for the other two OpSub destinations and for the three convert paths this touches.
Worth being upfront that they cannot isolate the hardening from the OpSub fix: with either one in
place nothing hands convert an untyped register, so no Haxe code can tell them apart. I confirmed
that by reverting CppiaCompiler.cpp completely with OpSub fixed, and everything still passes. To
check the new cases aren't vacuous I dropped a line from the branch I rewrote, and exactly one test
failed.

Since this is all in the JIT I wanted to be sure it doesn't shift any generated code, so I built
master and this branch as two hosts, ran both against the same client.cppia, and dumped the sljit
LIR. 2601 lines each, no differences once you normalise the pointer immediates that move around under
ASLR. Which makes sense: getData never looks at type, and getTarget only uses it to choose
between maxFTempCount and maxTempCount, where jtAny, jtInt and jtPointer all land in the
same branch. If anything it's a win, since "" + (a - b) used to throw at load, so those modules
couldn't be jitted at all.

@MeguminBOT
MeguminBOT marked this pull request as ready for review August 16, 2026 11:26
@MeguminBOT MeguminBOT changed the title Fix cppia JIT "Bad move target" on untyped register moves Fix cppia JIT "Bad move target" converting an int subtraction to a string Aug 16, 2026
@MeguminBOT

MeguminBOT commented Aug 16, 2026

Copy link
Copy Markdown
Author

Also the MacOS arm64 test seems to be very random wheter it passes or not.
So I think someone should take a look at that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant